Skip to content

feat: content-blind push notifications — daemon backbone (P2 push, slice 1) - #249

Merged
saucam merged 1 commit into
mainfrom
feat/push-notifications
Jul 26, 2026
Merged

feat: content-blind push notifications — daemon backbone (P2 push, slice 1)#249
saucam merged 1 commit into
mainfrom
feat/push-notifications

Conversation

@saucam

@saucam saucam commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

What

Push notifications — the daemon backbone (P2 push, slice 1 of 3). When a session blocks on a tool approval, the daemon now sends a content-blind wake-up to the session owner's registered devices off-LAN, behind a swappable PushTransport seam. Expo Push is the v1 transport; a self-hosted content-blind relay swaps in behind the same interface later.

This is the headless, device-free backbone. codeoid-mobile consumes it after the next codeoid release (slice 2); the self-hosted relay + iOS NSE + background Approve/Deny are slice 3.

Design

  • Content-blind by construction. The emit point (SessionManager.#statusObserver) only ever has (sessionId, status) in hand, and the payload carries only { sessionId, kind } + generic copy — never a tool name, args, or description. Even through a third-party transport (Expo), no session content leaves the daemon; the app resolves human-readable context over its authenticated socket on tap.
  • Swappable transport. PushTransport (src/daemon/push) is the seam: ExpoPushTransport (v1) → a self-hosted relay (.p8 APNs + FCM + NSE poll-back) later, with no redesign. config.push.transport: "expo" | "none" (default none).
  • Owner-scoped routing. push_registrations keys on the ZeroID identity (owner_sub == sessions.created_by), tenant-scoped by account/project — only the session's creator is alerted, never across tenants.
  • Off the hot path. Fire-and-forget at the single daemon-wide status chokepoint that fires regardless of client attachment; a best-effort transport that never throws, so a push hiccup can't touch the status path.

Surface

  • Protocol: push.register / push.unregister + a PUSH capability (advertised on auth.ok only when a transport is configured, so clients feature-detect).
  • Daemon: push_registrations store + owner-scoped CRUD; PushService; the emit; self-scoped handlers (no bespoke scope, mirroring session.ui_response).
  • Config: a push block + CODEOID_PUSH_TRANSPORT / CODEOID_EXPO_ACCESS_TOKEN env overrides.

Tests

bun test: store owner/tenant isolation + refresh + owner-scoped delete; Expo content-blindness + batching + auth header + error-swallow; the transport factory; PushService routing; register/unregister over the wire; and an end-to-end emit test that drives a real session to waiting_approval and asserts the outbound Expo payload is content-blind. Lint + typecheck clean; a regression net over protocol / core / config / session-manager is green.

Follow-ups

  • Slice 2 (codeoid-mobile, after a release): expo-notifications + register-on-connect + tap→approval + EAS dev build. First push lands on a phone here.
  • Slice 3: self-hosted content-blind relay + iOS Notification Service Extension + daemon reverse channel for background Approve/Deny.

🤖 Generated with Claude Code

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

…ice 1)

When a session blocks on a tool approval, the daemon now sends a
CONTENT-BLIND wake-up to the session owner's registered devices off-LAN,
behind a swappable PushTransport seam. Expo Push is the v1 transport; a
self-hosted content-blind relay swaps in behind the same interface later
(the migration is a transport change, not a redesign). codeoid-mobile
consumes this after the next codeoid release — this slice is the
headless, device-free backbone.

Protocol (@codeoid/protocol):
- push.register / push.unregister client messages + a PUSH capability +
  PUSH_TOKEN_MAX limit; zod schemas + the compile-time coverage samples.

Daemon:
- push_registrations SQLite table + owner-scoped CRUD, keyed on the
  ZeroID identity (owner_sub == sessions.created_by), tenant-scoped by
  account/project — so only the session's creator is alerted, never
  across tenants.
- PushTransport seam (src/daemon/push): ExpoPushTransport (batched at
  100/req, bare fetch, best-effort/never-throws) + a noop transport,
  selected by config.push.transport ("expo" | "none", default "none").
- PushService.notifyApproval resolves a blocked session's owner to their
  devices and delivers a content-blind { sessionId, kind } note — never
  a tool name, args, or description.
- Emit at SessionManager.#statusObserver — the single daemon-wide
  chokepoint that fires regardless of client attachment — gated on
  waiting_approval; fire-and-forget so a push hiccup can't touch the
  status path.
- push.register / push.unregister handlers, self-scoped to the caller's
  auth (no bespoke scope, mirroring session.ui_response).
- The daemon advertises the PUSH capability on auth.ok only when a
  transport is configured, so clients feature-detect push support.

Config: a push block + CODEOID_PUSH_TRANSPORT / CODEOID_EXPO_ACCESS_TOKEN
env overrides.

Tests (bun): push_registrations store (owner/tenant isolation, refresh,
owner-scoped delete); Expo transport content-blindness + batching + auth
header + error-swallow; the createPushTransport factory; PushService
routing; register/unregister handlers over the wire; and an end-to-end
emit test that drives a real session to waiting_approval and asserts the
outbound Expo payload is content-blind.

Signed-off-by: Yash Datta <yd2590@columbia.edu>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@saucam
saucam force-pushed the feat/push-notifications branch from 9723333 to db031dd Compare July 26, 2026 03:48
@saucam
saucam merged commit b93b7e1 into main Jul 26, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants